package in.uncod.android.bypass; import android.content.Context; import android.graphics.Color; import android.graphics.Typeface; import android.support.annotation.ColorInt; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; import android.text.style.ForegroundColorSpan; import android.text.style.LeadingMarginSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StrikethroughSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.util.DisplayMetrics; import android.util.Patterns; import android.util.TypedValue; import android.widget.TextView; import in.uncod.android.bypass.style.FancyQuoteSpan; import in.uncod.android.bypass.style.ImageLoadingSpan; import in.uncod.android.bypass.Element.Type; import in.uncod.android.bypass.style.HorizontalLineSpan; import in.uncod.android.bypass.style.TouchableUrlSpan; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class Bypass { static { System.loadLibrary("bypass"); } private final Options mOptions; private final int mListItemIndent; private final int mBlockQuoteIndent; private final int mBlockQuoteLineWidth; private final int mBlockQuoteLineIndent; private final int mCodeBlockIndent; private final int mHruleSize; private final int mHruleTopBottomPadding; // Keeps track of the ordered list number for each LIST element. // We need to track multiple ordered lists at once because of nesting. private final Map<Element, Integer> mOrderedListNumber = new ConcurrentHashMap<Element, Integer>(); public Bypass(Context context, Options options) { mOptions = options; DisplayMetrics dm = context.getResources().getDisplayMetrics(); mListItemIndent = (int) TypedValue.applyDimension(mOptions.mListItemIndentUnit, mOptions.mListItemIndentSize, dm); mBlockQuoteIndent = (int) TypedValue.applyDimension(mOptions.mBlockQuoteIndentUnit, mOptions.mBlockQuoteIndentSize, dm); mBlockQuoteLineWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mOptions.mBlockQuoteLineWidth, dm); mBlockQuoteLineIndent = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mOptions.mBlockQuoteLineIndent, dm); mCodeBlockIndent = (int) TypedValue.applyDimension(mOptions.mCodeBlockIndentUnit, mOptions.mCodeBlockIndentSize, dm); mHruleSize = (int) TypedValue.applyDimension(mOptions.mHruleUnit, mOptions.mHruleSize, dm); mHruleTopBottomPadding = (int) dm.density * 10; } private static void setSpan(SpannableStringBuilder builder, Object what) { builder.setSpan(what, 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } // These have trailing newlines that we want to avoid spanning private static void setBlockSpan(SpannableStringBuilder builder, Object what) { int length = Math.max(0, builder.length() - 1); builder.setSpan(what, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } private static void setSpanWithPrependedNewline(SpannableStringBuilder builder, Object what) { builder.setSpan(what, 1, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } private static void setPrependedNewlineSpan(SpannableStringBuilder builder, int height) { builder.setSpan(new AbsoluteSizeSpan(height, true), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } public CharSequence markdownToSpannable(String markdown, TextView textView, LoadImageCallback loadImageCallback) { Document document = processMarkdown(markdown); int size = document.getElementCount(); CharSequence[] spans = new CharSequence[size]; for (int i = 0; i < size; i++) { spans[i] = recurseElement(document.getElement(i), i, size, textView, loadImageCallback); } return TextUtils.concat(spans); } private native Document processMarkdown(String markdown); // The 'numberOfSiblings' parameters refers to the number of siblings within the parent, including // the 'element' parameter, as in "How many siblings are you?" rather than "How many siblings do // you have?". private CharSequence recurseElement(Element element, int indexWithinParent, int numberOfSiblings, TextView textView, LoadImageCallback loadImageCallback) { Type type = element.getType(); boolean isOrderedList = false; if (type == Type.LIST) { String flagsStr = element.getAttribute("flags"); if (flagsStr != null) { int flags = Integer.parseInt(flagsStr); isOrderedList = (flags & Element.F_LIST_ORDERED) != 0; if (isOrderedList) { mOrderedListNumber.put(element, 1); } } } int size = element.size(); CharSequence[] spans = new CharSequence[size]; for (int i = 0; i < size; i++) { spans[i] = recurseElement(element.children[i], i, size, textView, loadImageCallback); } // Clean up after we're done if (isOrderedList) { mOrderedListNumber.remove(this); } CharSequence concat = TextUtils.concat(spans); SpannableStringBuilder builder = new ReverseSpannableStringBuilder(); String text = element.getText(); if (element.size() == 0 && element.getParent() != null && element.getParent().getType() != Type.BLOCK_CODE) { text = text.replace('\n', ' '); } switch (type) { case LIST: if (element.getParent() != null && element.getParent().getType() == Type.LIST_ITEM) { builder.append("\n"); } break; case LINEBREAK: builder.append("\n"); break; case LIST_ITEM: builder.append(" "); if (mOrderedListNumber.containsKey(element.getParent())) { int number = mOrderedListNumber.get(element.getParent()); builder.append(Integer.toString(number) + "."); mOrderedListNumber.put(element.getParent(), number + 1); } else { builder.append(mOptions.mUnorderedListItem); } builder.append(" "); break; case AUTOLINK: builder.append(element.getAttribute("link")); break; case HRULE: // This ultimately gets drawn over by the line span, but // we need something here or the span isn't even drawn. builder.append("-"); break; case IMAGE: if (loadImageCallback != null && !TextUtils.isEmpty(element.getAttribute("link"))) { // prepend a new line so that images are always on a new line builder.append("\n"); // Display alt text (or title text) if there is no image String alt = element.getAttribute("alt"); if (TextUtils.isEmpty(alt)) { alt = element.getAttribute("title"); } if (!TextUtils.isEmpty(alt)) { alt = "[" + alt + "]"; builder.append(alt); } else { // Character to be replaced builder.append("\uFFFC"); } } else { // Character to be replaced builder.append("\uFFFC"); } break; } builder.append(text); builder.append(concat); // Don't auto-append whitespace after last item in document. The 'numberOfSiblings' // is the number of children the parent of the current element has (including the // element itself), hence subtracting a number from that count gives us the index // of the last child within the parent. if (element.getParent() != null || indexWithinParent < (numberOfSiblings - 1)) { if (type == Type.LIST_ITEM) { if (element.size() == 0 || !element.children[element.size() - 1].isBlockElement()) { builder.append("\n"); } } else if (element.isBlockElement() && type != Type.BLOCK_QUOTE) { if (type == Type.LIST) { // If this is a nested list, don't include newlines if (element.getParent() == null || element.getParent().getType() != Type.LIST_ITEM) { builder.append("\n"); } } else if (element.getParent() != null && element.getParent().getType() == Type.LIST_ITEM) { // List items should never double-space their entries builder.append("\n"); } else { builder.append("\n\n"); } } } switch (type) { case HEADER: String levelStr = element.getAttribute("level"); int level = Integer.parseInt(levelStr); setSpan(builder, new RelativeSizeSpan(mOptions.mHeaderSizes[level - 1])); setSpan(builder, new StyleSpan(Typeface.BOLD)); break; case LIST: setBlockSpan(builder, new LeadingMarginSpan.Standard(mListItemIndent)); break; case EMPHASIS: setSpan(builder, new StyleSpan(Typeface.ITALIC)); break; case DOUBLE_EMPHASIS: setSpan(builder, new StyleSpan(Typeface.BOLD)); break; case TRIPLE_EMPHASIS: setSpan(builder, new StyleSpan(Typeface.BOLD_ITALIC)); break; case BLOCK_CODE: setSpan(builder, new LeadingMarginSpan.Standard(mCodeBlockIndent)); setSpan(builder, new TypefaceSpan("monospace")); break; case CODE_SPAN: setSpan(builder, new TypefaceSpan("monospace")); break; case LINK: case AUTOLINK: String link = element.getAttribute("link"); if (!TextUtils.isEmpty(link) && Patterns.EMAIL_ADDRESS.matcher(link).matches()) { link = "mailto:" + link; } setSpan(builder, new TouchableUrlSpan(link, textView.getLinkTextColors(), textView.getHighlightColor())); break; case BLOCK_QUOTE: // We add two leading margin spans so that when the order is reversed, // the QuoteSpan will always be in the same spot. setBlockSpan(builder, new LeadingMarginSpan.Standard(mBlockQuoteIndent)); //setBlockSpan(builder, new QuoteSpan(mOptions.mBlockQuoteLineColor)); setBlockSpan(builder, new FancyQuoteSpan(mBlockQuoteLineWidth, mBlockQuoteLineIndent, mOptions.mBlockQuoteLineColor)); setBlockSpan(builder, new ForegroundColorSpan(mOptions.mBlockQuoteTextColor)); setBlockSpan(builder, new LeadingMarginSpan.Standard(mBlockQuoteIndent)); setBlockSpan(builder, new StyleSpan(Typeface.ITALIC)); break; case STRIKETHROUGH: setSpan(builder, new StrikethroughSpan()); break; case HRULE: setSpan(builder, new HorizontalLineSpan(mOptions.mHruleColor, mHruleSize, mHruleTopBottomPadding)); break; case IMAGE: String url = element.getAttribute("link"); if (loadImageCallback != null && !TextUtils.isEmpty(url)) { setPrependedNewlineSpan(builder, mOptions.mPreImageLinebreakHeight); ImageLoadingSpan loadingSpan = new ImageLoadingSpan(); setSpanWithPrependedNewline(builder, loadingSpan); // make the (eventually loaded) image span clickable to open in browser setSpanWithPrependedNewline(builder, new TouchableUrlSpan(url, textView.getLinkTextColors(), textView.getHighlightColor())); loadImageCallback.loadImage(url, loadingSpan); } break; } return builder; } public interface LoadImageCallback { /** * A callback to load an image found in a markdown document. * @param src The source (url) of the image. * @param loadingSpan A placeholder span making where the image should be inserted. */ void loadImage(String src, ImageLoadingSpan loadingSpan); } /** * Configurable options for how Bypass renders certain elements. */ public static final class Options { private float[] mHeaderSizes; private String mUnorderedListItem; private int mListItemIndentUnit; private float mListItemIndentSize; private int mBlockQuoteLineColor; private int mBlockQuoteTextColor; private int mBlockQuoteLineWidth; private int mBlockQuoteLineIndent; private int mBlockQuoteIndentUnit; private float mBlockQuoteIndentSize; private int mPreImageLinebreakHeight; private int mCodeBlockIndentUnit; private float mCodeBlockIndentSize; private int mHruleColor; private int mHruleUnit; private float mHruleSize; public Options() { mHeaderSizes = new float[]{ 1.5f, // h1 1.4f, // h2 1.3f, // h3 1.2f, // h4 1.1f, // h5 1.0f, // h6 }; mUnorderedListItem = "\u2022"; mListItemIndentUnit = TypedValue.COMPLEX_UNIT_DIP; mListItemIndentSize = 10; mBlockQuoteLineColor = 0xff0000ff; mBlockQuoteIndentUnit = TypedValue.COMPLEX_UNIT_DIP; mBlockQuoteIndentSize = 10; mPreImageLinebreakHeight = 4; mCodeBlockIndentUnit = TypedValue.COMPLEX_UNIT_DIP; mCodeBlockIndentSize = 10; mHruleColor = Color.GRAY; mHruleUnit = TypedValue.COMPLEX_UNIT_DIP; mHruleSize = 1; } public Options setHeaderSizes(float[] headerSizes) { if (headerSizes == null) { throw new IllegalArgumentException("headerSizes must not be null"); } else if (headerSizes.length != 6) { throw new IllegalArgumentException("headerSizes must have 6 elements (h1 through h6)"); } mHeaderSizes = headerSizes; return this; } public Options setUnorderedListItem(String unorderedListItem) { mUnorderedListItem = unorderedListItem; return this; } public Options setListItemIndentSize(int unit, float size) { mListItemIndentUnit = unit; mListItemIndentSize = size; return this; } public Options setBlockQuoteLineColor(@ColorInt int color) { mBlockQuoteLineColor = color; return this; } public Options setBlockQuoteTextColor(@ColorInt int color) { mBlockQuoteTextColor = color; return this; } public Options setBlockQuoteLineWidth(int width) { mBlockQuoteLineWidth = width; return this; } public Options setBlockQuoteLineIndent(int indent) { mBlockQuoteLineIndent = indent; return this; } public Options setBlockQuoteIndentSize(int unit, float size) { mBlockQuoteIndentUnit = unit; mBlockQuoteIndentSize = size; return this; } public Options setPreImageLinebreakHeight(int height) { mPreImageLinebreakHeight = height; return this; } public Options setCodeBlockIndentSize(int unit, float size) { mCodeBlockIndentUnit = unit; mCodeBlockIndentSize = size; return this; } public Options setHruleColor(@ColorInt int color) { mHruleColor = color; return this; } public Options setHruleSize(int unit, float size) { mHruleUnit = unit; mHruleSize = size; return this; } } }